由结构体对齐所引发的对C++类对象内存模型的思考(一)
(注:本文的实验环境是在VS201X中进行的)
结构体对齐
一般而言,结构体变量内存中成员的排布如下:(从第一个成员依次向下排)
结构体数据的地址开始于第一个声明的成员的地址,结束于最后一个成员的地址。在他们中间,按照声明的顺序存储着所有的数据成员,但真相远非如此,还存在着内存对齐的问题。
对于类似于下面这样的结构体:
struct Test {
char a;
double b;
char c;
};
int _tmain(int argc, _TCHAR* argv[])
{
Test obj;
obj.a = 'a';
obj.b = 1564654.325;
obj.c = 'b';
cout << sizeof(Test);
}
其输出结果为:24
有些刚学的童鞋可能会问:char占一个字节,double占八个字节,8+1+1不是等于10么,难道电脑出问题了???好,为了一探究竟,下面我们查看其内存:
0x0036FAC8是obj的起始地址,也就是char类型的成员a的地址,但是比较奇怪的是,在a后面并没有仅接着就存储b,而是空了7个字节之后再存储b,b占了8个字节,之后c占据了一个字节之后(在内存中已经显示出b),整个结构体并未结束,在其后又空置了7个字节,故整个结构体占24个字节.这对于不了解内存对齐规则的人来说是很困惑的。
内存对齐规则
通过查阅相关文献,内存对齐规则主要为以下三点:
数据的第一个成员存储在结构体的起始位置,偏移为0
数据的后续成员存储的偏移位置为某一个数的整数倍
1.上个规则中的某一个数为编译器默认的对齐数与这个数据成员的字节数这两个数中的较小值。整个结构体的尺寸为结构体中每一次安排的某一个数中的最大值的最小整数倍。
下面结合之前的那个代码对以上规则进行解释:
第一个成员a为字符型,占一个字节,存储的位置是0x0036FAC8,存储之后,第二个成员b为双精度浮点型,占8个字节,同时编译器默认规定的对齐数为8,这两个数取较小值,还是8,所以它存储的位置偏移应该为8的整数倍,刚好0x0036FAC8-0x0036FAD0为8,因此在0x0036FAD0这个地方存储b占用8个字节,之后准备开始存储c,c是字符型,占据一个字节,系统默认对齐数为8,二者取其小,它存储的地址应该是1的整数倍,很明显,任何偏移都是1的整数倍,直接存在了b的后面。到这里本该结束了,但是还有规则3,即在进行b的存储的时候选出来的某个数是8,在存储c的时候选出来的某个数是1,在8和1中选出一个最大值,结构体的大小应该为8的最小整数倍,现在的结构体大小为8+8+1也就是17,最小整数倍只能是24了。所以在c的后面又空置了7个字节。促成了结构体的24个字节的大小。
还有两点要说明:
1 以上提到了一个编译器默认的对齐数,这个数是可以更改的。使用#pragma pack (1//2//4//8//16)来更改,只能改为1,2,4,8,16中的一个值。
2 在网络传输,使用结构体指针指向某一个数据区获取数据,读取文件等时候,经常由于结构体的对齐问题而出错,是一个值得注意的问题。
实战练习
希望通过以下的例子能够再次熟悉结构体的对齐:
struct Test {
char a;
double b;
int n;
};
int main()
{
Test obj;
obj.a = 'c';
obj.b = 889089;
obj.n = 1234;
cout << sizeof(Test);
return 0;
}
输出结果:24
struct Test {
char a;
int n;
double b;
};
int main()
{
Test obj;
obj.a = 'c';
obj.b = 889089;
obj.n = 1234;
cout << sizeof(Test);
return 0;
}
输出结果:16
不知各位读者注意到没,结构体中不同类型的数据排列不同,得到的结构体大小也将不同,就是由于内存对齐所造成的。
类对象内存模型
1. 普通类的内存模型:
从一段简单的代码看起:
class Test {
public:
char a;
double b;
char c;
static int d;
static int e;
void fun()
{
printf("Hello world!");
}
};
int main()
{
Test Teobj;
Teobj.a = 'a';
Teobj.b = 1564654.325;
Teobj.c = 'b';
cout << sizeof(Test);
return 0;
}
输出结果为24;
我们把上一篇的例子中struct改成class关键字,再在class中添加pubilc属性,保证在外部能访问数据,又添加了两个静态成员和一个成员函数。显示结果与结构体是一模一样的。
由此可以验证:
vs2015c++中结构体与类的区别只是内部默认访问属性的不同。
对于一个没有继承别的类的类,他的内存模型与结构体一模一样,也就是说内存对齐也是一样的。
静态成员与成员函数不会对内存模型造成任何影响。
2. 有继承关系时对象的内存模型
这里是需要我们重点研究的,在开始之前先思考这样一个问题,请看图:
类B派生自类A,类C除了没有继承自类A外,与类B一模一样。所有的类没有虚继承与虚函数,此时:
sizeof(类A)+sizeof(类C)与sizeof(类B)之间的大小关系如何呢?
是不是相等的呢?为了解答心中的这个疑惑,好,首先让我们看代码:
class Base
{
public:
Base() :a(0x1), b(0x2), c(0x3),d('d'), e('e') {}
int a;
int b;
int c;
double d;
char e;
};
class Inherit1 :public Base
{
public:
Inherit1() :m_Inherit1a(0xF) {}
int m_Inherit1a;
};
class CTest
{
public:
CTest() :m_Inherit1a(0xF) {}
int m_Inherit1a;
};
int main() {
Base obj1;
CTest obj2;
Inherit1 obj3;
cout << "基类大小 :" << sizeof(obj1) << endl;
cout << "派生类大小 :" << sizeof(obj3) << endl;
cout << "测试类大小:" << sizeof(obj2) << endl;
return 0;
}
其输出结果为:
我们发现结果出乎我们的意料,sizeof(基类)+sizeof(测试类)与sizeof(派生类)居然不相等!那是为什么呢?
下面我们来分析这个结果:
首先看一看派生类对象,其内存模型图如下:
可以看出前三个值是1,2,3,也就是在基类构造函数中初始化的数据成员,可以验证,在派生类对象中,开始存放的是基类的数据成员。前3个int型成员占据12个字节后,之后是double型,依据之前的内存对齐规则,我们可以推测,成员d所在位置偏移需要为8的整数倍,故而再隔了4个字节之后开始存放d,而上图正好验证了我们的推测!随后存放成员e,此时基类大小已经为25了,但是基类总大小应该为8的整数倍(参见上面的内存规则),所以后面又空置了7个字节(用cc来填充),随后存储的是派生类中的数据成员,仅有一个int型,总大小是36,但是真相并非如此,后面又补了4个字节,那是因为整个派生类对象的对齐同时考虑基类和派生类,故而大小为8的整数倍。
我们单看基类和测试类,它们大小分别为32字节和4字节,它们之间是没有继承关系的,有了继承关系组合到一起后的派生类竟然超过没有继承关系时两个类大小的总和!有点意思.既然基类和派生类会"扩充"变大,那么它们会融合么??
下面看代码:
class Base
{
public:
Base() :a(0x1), b(0x2) {}
int a;
char b;
};
class Inherit1 :public Base
{
public:
Inherit1() :m_Inherit1a(0xF) {}
char m_Inherit1a;
};
class CTest
{
public:
CTest() :m_Inherit1a(0xF) {}
char m_Inherit1a;
};
int main()
{
Base obj1;
CTest obj2;
Inherit1 obj3;
cout << "基类大小 :" << sizeof(obj1) << endl;
cout << "测试类大小:" << sizeof(obj2) << endl;
cout << "派生类大小 :" << sizeof(obj3) << endl;
return 0;
}
输出结果:
派生类内存模型如下:
可以看出,临近的两个char类型数据并没有融合到一起,基类与子类泾渭分明。并且又由于整体对齐的原因,又是继承关系的子类大小大于基类与测试类大小的总和。
下面,我们再看一段代码 :
class Base
{
public:
Base() :a(0x1), b(0x2), c(0x3), d('a'), e('a') {}
int a;
int b;
int c;
int d;
char e;
};
class Inherit1 :public Base
{
public:
Inherit1() :c1('A'), m_Inherit1a(0xF) {}
char c1;
double m_Inherit1a;
};
class CTest
{
public:
CTest() :c('A'), m_Inherit1a(0xF) {}
char c;
double m_Inherit1a;
};
int main()
{
Base obj1;
CTest obj2;
Inherit1 obj3;
cout << "基类大小 :" << sizeof(obj1) << endl;
cout << "测试类大小:" << sizeof(obj2) << endl;
cout << "派生类大小 :" << sizeof(obj3) << endl;
return 0;
}
输出结果:
查看派生类对象内存模型:
可以看出,父类与子类,依然泾渭分明,派生类部分被挤榨的仅为12个字节,整体上来说32字节还是保证了为8的倍数。此时就出现了让我们大跌眼镜的一步,具有继承关系的派生类的大小竟然小于父类与测试类大小的和。
通过之前的分析,我们可以得出具有继承关系的派生类的大小既可以大于也可以小于测试类大小的和,那么可以等于么?答案是显而易见的。
下面我们看代码:
class Base
{
public:
Base() :a(0x1), b(0x2), c(0x3), d('a'), e('a') {}
int a;
int b;
int c;
int d;
int e;
};
class Inherit1 :public Base
{
public:
Inherit1() :c1(0x8), m_Inherit1a(0xF) {}
int c1;
int m_Inherit1a;
};
class CTest
{
public:
CTest() :c(0x8), m_Inherit1a(0xF) {}
int c;
int m_Inherit1a;
};
int main()
{
Base obj1;
CTest obj2;
Inherit1 obj3;
cout << "基类大小 :" << sizeof(obj1) << endl;
cout << "测试类大小:" << sizeof(obj2) << endl;
cout << "派生类大小 :" << sizeof(obj3) << endl;
return 0;
}
输出结果:
至于这里为什么是等于的,我想通过之前的分析,大家应该明白其中的缘由,这里就不多说了.
总结:
当为普通继承关系时,基类成员在派生类成员的上面
基类与子类的成员不会融合,也就是说,会保证基类大小是对齐过的
无论是基类成员还是派生类成员,排布其所在位置的偏移都是相对于整个类对象的起始位置。
静态成员与成员函数对于类对象的内存排布没有任何影响.
我们现在可以回答一开始提出的问题了,有可能sizeof(类A)+sizeof(类C)>sizeof(类B),也可能sizeof(类A)+sizeof(类C)<sizeof(类B),也可能sizeof(类A)+sizeof(类C)==sizeof(类B),这真是让人惊讶不已!3.派生类与基类之间的转换
这里紧接着上面探讨一下子类与父类之间的关系,当我们按int型访问一个地址中的值时,就会取出这个地址到这个地址加4这段区间存储的数据,同样,我们按一个类的类型去访问一个地址中的值时,就会按照这个类的大小,去访问以这个地址值为起点的一段连续的地址空间。
class Base
{
public:
Base() :a(0x1), b(0x2) {}
int a;
char b;
};
class Inherit1 :public Base
{
public:
Inherit1() :m_Inherit1a(0xF) {}
char m_Inherit1a;
};
class Inherit2 :Base
{
public:
int m_Inherit2a;
int m_Inherit2b;
};
int main()
{
Base obj1;
Inherit1 obj2;
Inherit2 obj3;
Base* pBase;
obj1 = obj2; //不报错
obj1 = obj3; //报错
obj1 = (Base)obj3; //报错
pBase = &obj2; //不报错
pBase = &obj3; //报错
pBase = (Base*)&obj3;//不报错
return 0;
}
这段代码中,公有继承的子类对象或者指针是可以直接赋值给父类的。Inherit2 是私有继承自Base类的,当派生类是私有或者保护继承于基类时,是不能直接把派生类的地址赋值给基类的,需要强制转换。
一般我们使用的都是指针,当我们把派生类的地址赋值给一个基类的时候,基类就会按照基类的方式去访问这段内存,不过,派生类的开头存储的正是由基类继承来的数据,类对象的内存模型完美支持这一点。另外从面向对象的角度来看,这么做是很合理的,比如动物类和兔子类,很明显兔子是动物,自然能够赋值,但是反过来,我说动物是兔子,这就未免会有问题了,下面探讨一下基类向派生类的转换。
当基类转换为派生类的时候,就按照派生类的方式去访问内存,在基类的内存中这块区域中,应该没有什么问题,但是当越过基类内存的时候,能访问到什么,修改了什么就很难说了。
int main()
{
Base obj1;
Inherit1 obj2;
Inherit2 obj3;
Base* pBase;
Inherit1* pInherit1;
Inherit2* pInherit2;
obj1 = obj2; //不报错
pBase = &obj2; //不报错
pBase = (Base*)&obj3;//不报错
obj2 = (Inherit1)obj1; //报错
pInherit1 = (Inherit1*)&obj1;
pInherit2 = (Inherit2*)&obj1;
return 0;
}
基类向派生类进行转换需要强制转换,并且也仅限于指针。
总结
公有继承的子类可以自由的向父类转换,内存模型支持这一点,语法上也支持这么做。
私有继承的子类需要强制转换。
父类不能随意向子类转换,一般来说这是不安全的,极有可能造成越界。
本文由看雪论坛 蓝色淡风 原创
转载请注明来自看雪社区
热门阅读
点击阅读原文/read,
更多干货等着你~